Skip to content

fix(transform): make the mid-turn release valve actually detect injections - #273

Merged
ualtinok merged 2 commits into
cortexkit:masterfrom
iceteaSA:mid-turn-valve-part-join
Aug 6, 2026
Merged

fix(transform): make the mid-turn release valve actually detect injections#273
ualtinok merged 2 commits into
cortexkit:masterfrom
iceteaSA:mid-turn-valve-part-join

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The bug

hasNewerRealUserMessage (read-session-db.ts:133) decides whether a newer real user message exists after the latest assistant turn — isMidTurnFromOpenCodeDb uses it to release the mid-turn lock. It filtered on $.synthetic on the message row:

AND COALESCE(json_extract(data, '$.synthetic'), 0) NOT IN (1, 'true')

OpenCode persists synthetic on the part row's data, never on the message row. Measured on a live opencode.db:

probe rows
json_extract(data,'$.synthetic') on message 0
json_extract(data,'$.synthetic') on part 11,320

So the predicate is always true. Every synthetic injection — Channel-2 ceiling nudges, s2s frames, subagent-message notifications — counts as a real user message and releases the mid-turn lock while a turn is still accumulating tool calls. The lock exists so the transform never rewrites bytes mid-turn, so this quietly gives up that protection.

Two things kept it hidden: the comment above the return asserted the opposite ("OpenCode persists promptAsync/channel-2 synthetic prompts as message.info.synthetic, which is the top-level $.synthetic field in the message table's data JSON"), and the existing test fixtured synthetic: true on the message row — a shape production never produces, which passes only because the filter reads that field. Comment and test agreed with each other and both were wrong.

The fix

A part join. A part is machine-generated iff it carries synthetic: true OR a metadata.marker.kind; a user message is injected iff it HAS parts AND EVERY part is machine-generated.

All three pieces are load-bearing, and each was found by measurement rather than reasoning:

1. marker.kind is required. Marker parts — s2s inbox lines, subagent-message notifications, steer/cancel notices — are deliberately non-synthetic so the TUI renders them as visible system-event lines (packages/tui/src/routes/session/index.tsx ≈1431-1441: !x.synthetic && metadata.marker.kind !== undefined). They're tagged structurally instead. Live counts: 1,778 such parts (message 847, inbox 683, interrupt 248). Testing synthetic alone misses all of them.

2. ALL-parts, not ANY. 1,813 user messages carry a synthetic part and a non-synthetic companion. Of those, 525 are genuine operator prompts that used an @mention — the mention adds a synthetic agent part beside real human text. ANY-semantics would classify those live human turns as injected and suppress the lock on real input: the inverse bug, and worse than the original.

3. The EXISTS (… FROM part …) clause is the vacuous-ALL fence. A partless user message satisfies "every part is machine-generated" trivially and must count as real. Partless user rows do occur — 4 of 19,004 on the box measured — so this is a live case, not a theoretical one.

The misleading comment is replaced with one that states where synthetic actually lives and why each clause exists.

Tests

Rewrote the existing synthetic test to fixture the part-level shape (it still expects true — still mid-turn), and added four cases:

case expectation guards
synthetic part after stale tool-calls tail mid-turn true the original bug
marker part (metadata.marker.kind: "inbox") mid-turn true the marker.kind clause
@mention operator prompt (real text + synthetic agent part) releases → false ANY-semantics inversion
partless user message releases → false vacuous-ALL fence
marker part + plain operator text releases → false mixed-parts

The synthetic-part and marker-part cases are red-verified against the unfixed query:

(fail) does not release mid-turn for synthetic-part user messages after a stale tool-calls tail
(fail) does not release mid-turn for marker-part user messages after a stale tool-calls tail
 18 pass / 2 fail

Restoring the fix: 20 pass / 0 fail.

Gates

gate result
read-session-db.test.ts 20 pass / 0 fail
plugin bun test 3357 pass / 0 fail (clean-master baseline 3353; +4 net)
tsc --noEmit clean
lint no diagnostics in either touched file

CI note: Check (plugin), Check (pi-plugin) and Check (dashboard) are currently failing on master itself (7af5961d) with lint errors in files this PR doesn't touch — compartment-chunk-embedding.ts, storage-git-commits.ts, render-mural.test.ts, embedding-local.test.ts and others. This branch inherits that red; it doesn't add to it. Happy to include the lint cleanup here if you'd rather, but it seemed wrong to bundle unrelated fixes into a behavioral change.

Note on scope

I deliberately did not propose tagging the marker/companion parts synthetic to make a single-flag test work — that would erase every ✉/⊘ system-event line from the operator's transcript, per the TUI predicate above. Separately worth flagging for maintainers: metadata.marker.kind appears to be the real "machine-generated" discriminator but isn't documented as such, so a consumer reaching for the obvious synthetic flag gets the classification wrong in both directions. A short note in the docs, or a dedicated systemGenerated field, would prevent the next consumer repeating this. Happy to file that separately if useful.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes mid-turn lock detection so synthetic, marker-only, and ignored-only user messages no longer release the lock. This prevents premature unlocks while tool calls are still running.

  • Bug Fixes
    • Detect injections at the part level; a user message is injected only if it has parts and every part is machine-generated (synthetic, metadata.marker.kind, or ignored).
    • Add an EXISTS guard so partless user messages count as real input.
    • Update tests with real marker shapes and cases for @mentions/mixed parts, partless, ignored-only, ignored+real text, numeric ignored: 1, and interrupt/message markers.

Written for commit 1ce9b36. Summary will update on new commits.

Review in cubic

Greptile Summary

The PR corrects mid-turn release detection by classifying user messages from their associated parts instead of a nonexistent message-level synthetic flag.

  • Treats messages as injected only when they contain parts and every part is synthetic, marker-tagged, or ignored.
  • Preserves real-user classification for partless and mixed-part messages.
  • Expands coverage for synthetic, marker, ignored, mixed, partless, and @mention message shapes.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/plugin/src/hooks/magic-context/read-session-db.ts Replaces the ineffective message-level synthetic check with all-parts classification and a partless-message guard.
packages/plugin/src/hooks/magic-context/read-session-db.test.ts Adds representative coverage for synthetic, marker, ignored, mixed, partless, and mention-bearing user messages.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Newer user message] --> B{Has parts?}
    B -- No --> R[Real user message: release lock]
    B -- Yes --> C{Every part synthetic, marker-tagged, or ignored?}
    C -- Yes --> I[Injected message: keep lock]
    C -- No --> R
Loading

Reviews (2): Last reviewed commit: "fix(transform): treat ignored-only user ..." | Re-trigger Greptile

…tions

`hasNewerRealUserMessage` tested `$.synthetic` on the MESSAGE row, but
OpenCode persists that flag on the PART row's data — never on the message.
Measured on a live opencode.db: message-level `$.synthetic` matches 0 rows,
part-level matches 11,320. The predicate was therefore always true, so every
synthetic injection (Channel-2 nudges, s2s frames, subagent notifications)
counted as a real user message and released the mid-turn lock while a turn
was still accumulating tool calls.

The comment above the return asserted the opposite ("message.info.synthetic,
which is the top-level $.synthetic field in the message table's data JSON"),
and the existing test fixtured `synthetic: true` on the message row — a shape
production never produces. Comment and test agreed with each other, which is
why the inert filter survived.

Replace it with a part join. A part is machine-generated iff it carries
`synthetic: true` OR a `metadata.marker.kind`; a user message is injected iff
it HAS parts AND EVERY part is machine-generated.

Both clauses are load-bearing:

- Marker parts (s2s inbox lines, subagent-message notifications, steer/cancel
  notices) are deliberately NON-synthetic so the TUI renders them as visible
  system-event lines, and are tagged structurally via `metadata.marker.kind`
  instead. Testing `synthetic` alone misses them.
- ALL-parts rather than ANY: an @mention puts a synthetic `agent` part on a
  genuine operator prompt, so ANY-semantics would classify live human turns as
  injected and suppress the lock on real input — the inverse bug, and worse.
- The `EXISTS (… FROM part …)` clause is the vacuous-ALL fence: a partless user
  message satisfies "every part is machine-generated" trivially and must count
  as real. Partless user rows do occur (4 of 19,004 on the box measured).

Rewrote the misleading test to fixture the part-level shape, and added cases
for marker parts, @mention prompts, partless messages, and mixed
marker-plus-operator-text. The synthetic-part and marker-part cases are
red-verified against the unfixed query.
@iceteaSA

iceteaSA commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

CI is red on this PR, and it's pre-existing on master — but the reason is more interesting than "master is red", so here's the measurement.

The three Check jobs fail identically on clean master:

upstream/master 7af5961d:  Check (plugin) => failure
                           Check (pi-plugin) => failure
                           Check (dashboard) => failure
this PR   26b39824:        identical three

All three fail at the Lint step, and none of the diagnostics are in files this PR touches — the whole diff is two files (read-session-db.ts, read-session-db.test.ts).

Why it doesn't reproduce locally: the Check job's lint gate is version-unstable. Line 48 of .github/workflows/ci.yml runs bun install (not --frozen-lockfile, unlike the E2E jobs at 160/193/233/303), and @biomejs/biome is specified ^2.5.1. So CI resolves whatever the caret currently allows — today 2.5.7 — while the lockfile pins 2.5.1. Reproduced both:

biome plugin package
2.5.1 (lockfile / local) 1 error, 44 warnings
2.5.7 (CI resolves) 19 errors, 44 warnings

Same tree, same command. The new errors are rules that tightened between patches — noUnusedVariables on StoredModelIdRow (compartment-chunk-embedding.ts:33), organizeImports in embedding-local.test.ts, plus others in storage-git-commits.ts, storage-git-commit-embeddings.ts, render-mural.test.ts, compress-cues.ts. Under CI's 2.5.7 my two touched files still produce 0 diagnostics, so this branch neither adds to nor subtracts from that count.

Practical consequence for the repo, independent of this PR: bun run lint passing locally isn't a reliable predictor of the Check job, and the gate can go red with no commit at all — just a biome patch release. Adding --frozen-lockfile to the Check job's install (matching the E2E jobs) would make it deterministic; pinning biome exactly would too. Happy to send that as a separate one-line PR if you want it — it seemed out of scope to fold a CI-config change into a behavioral fix, and it's your call whether the right move is freezing the install or absorbing the 2.5.7 fixes.

Local gates on this branch, for the record: read-session-db.test.ts 20 pass / 0 fail · plugin suite 3357 pass / 0 fail (clean-master baseline 3353) · tsc --noEmit clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/plugin/src/hooks/magic-context/read-session-db.ts">

<violation number="1" location="packages/plugin/src/hooks/magic-context/read-session-db.ts:152">
P2: The whole fix now depends on two external-schema assumptions: (1) that `synthetic` is persisted on the `part` row rather than the `message` row, and (2) that system-event (inbox/s2s) parts expose their kind at `data.metadata.marker.kind`. If the real OpenCode part shape differs from `metadata.marker.kind` — e.g. the marker data is stored directly on the part instead of nested under `metadata` — then marker parts would not be detected as machine-generated, and a user message whose only part is a marker would be misclassified as real, releasing the mid-turn lock (exactly the inverse bug the PR set out to fix). The new tests currently construct fixtures inside `insertPart(...)` using the same `metadata: { marker: { kind: ... } }` assumption the code reads, so they are self-confirming and cannot catch a mismatch with the real persisted shape. The PR itself already flags that `marker.kind` is 'not currently documented'. Please verify the marker path against a real captured OpenCode part row and consider adding a fixture sourced from actual persisted data rather than the assumed shape.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

SELECT 1 FROM part p
WHERE p.message_id = m.id
AND COALESCE(json_extract(p.data, '$.synthetic'), 0) NOT IN (1, 'true')
AND json_extract(p.data, '$.metadata.marker.kind') IS NULL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The whole fix now depends on two external-schema assumptions: (1) that synthetic is persisted on the part row rather than the message row, and (2) that system-event (inbox/s2s) parts expose their kind at data.metadata.marker.kind. If the real OpenCode part shape differs from metadata.marker.kind — e.g. the marker data is stored directly on the part instead of nested under metadata — then marker parts would not be detected as machine-generated, and a user message whose only part is a marker would be misclassified as real, releasing the mid-turn lock (exactly the inverse bug the PR set out to fix). The new tests currently construct fixtures inside insertPart(...) using the same metadata: { marker: { kind: ... } } assumption the code reads, so they are self-confirming and cannot catch a mismatch with the real persisted shape. The PR itself already flags that marker.kind is 'not currently documented'. Please verify the marker path against a real captured OpenCode part row and consider adding a fixture sourced from actual persisted data rather than the assumed shape.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/read-session-db.ts, line 152:

<comment>The whole fix now depends on two external-schema assumptions: (1) that `synthetic` is persisted on the `part` row rather than the `message` row, and (2) that system-event (inbox/s2s) parts expose their kind at `data.metadata.marker.kind`. If the real OpenCode part shape differs from `metadata.marker.kind` — e.g. the marker data is stored directly on the part instead of nested under `metadata` — then marker parts would not be detected as machine-generated, and a user message whose only part is a marker would be misclassified as real, releasing the mid-turn lock (exactly the inverse bug the PR set out to fix). The new tests currently construct fixtures inside `insertPart(...)` using the same `metadata: { marker: { kind: ... } }` assumption the code reads, so they are self-confirming and cannot catch a mismatch with the real persisted shape. The PR itself already flags that `marker.kind` is 'not currently documented'. Please verify the marker path against a real captured OpenCode part row and consider adding a fixture sourced from actual persisted data rather than the assumed shape.</comment>

<file context>
@@ -139,18 +139,35 @@ function hasNewerRealUserMessage(
+                   SELECT 1 FROM part p
+                   WHERE p.message_id = m.id
+                     AND COALESCE(json_extract(p.data, '$.synthetic'), 0) NOT IN (1, 'true')
+                     AND json_extract(p.data, '$.metadata.marker.kind') IS NULL
+                 )
+               )
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self-confirming-fixture criticism is correct on method, and I've addressed it by sourcing the fixtures from real data. Both assumptions verified against a live opencode.db rather than reasoned about.

Assumption 1 — synthetic on part, not message. This is what the PR is fixing, and it's measurable: json_extract(data,'$.synthetic') on the message table returns 0 rows across the entire DB, versus 11,320 rows on part. The old message-level predicate could never match, which is why the valve was inert.

Assumption 2 — metadata.marker.kind. Present and populated: 740 inbox, 168 interrupt, 805 message. Verbatim part.data.metadata from real rows:

{"marker":{"kind":"inbox","from":"","sessionId":""}}
{"marker":{"kind":"interrupt","intent":"abort","origin":"parent"}}
{"marker":{"kind":"message","peer":"subagent","expectReply":false}}

The fixtures now use these shapes (session id and peer name replaced with placeholders) instead of the invented { marker: { kind } }. That closes the gap you identified two ways: the path is confirmed against persisted data, and because the real shapes carry sibling fields beside kind, the tests now also prove the predicate isn't accidentally depending on marker having exactly one key. Coverage widened from inbox alone to all three kinds.

On the inverse-bug risk specifically: I'd rather not rest on fixtures alone, so it's worth noting the ALL-parts semantics gives a second line of defense. For a marker path mismatch to release the lock, the message would need every part to be undetectable — and I checked what happens under ANY-semantics instead: 534 messages would flip, 525 of them genuine human prompts containing @mentions (an @mention appends a synthetic agent part to a real prompt). That asymmetry is why ALL is the correct quantifier here, and it's now covered by an explicit @mention test.

Worth flagging one thing I could not verify: marker.kind is real in persisted data but is not in a published schema I can find, so it remains an undocumented shape we depend on. If it moves, the failure is silent. I'd take a stable discriminator upstream if one is offered.

Comment thread packages/plugin/src/hooks/magic-context/read-session-db.ts
…id-turn valve

`hasNewerRealUserMessage` classified a user message as REAL whenever any part
was non-synthetic and carried no `metadata.marker.kind`. Parts flagged
`ignored: true` satisfied both conditions, so an ignored-only message released
the mid-turn lock and allowed a tail rewrite while a tool turn was still
running.

Measured on a live opencode.db: 102 ignored parts, all on user messages, none
synthetic, none carrying a marker — and every one of those messages was
ignored-ONLY, so all 102 released the lock. They are plugin status
notifications (bodies like "## Claude Routing Status", "## Claude Quotas").

What settles the classification is opencode's own serializer, not our
inference. message-v2.ts:206 keeps a user text part only when
`!part.ignored && part.text !== ""`, so an ignored part never reaches the
model. A message whose parts are all ignored contributes nothing to the prompt
and therefore cannot be a real user turn.

Adds a third machine-generated clause to the inner NOT EXISTS witness, using
the same COALESCE truthiness convention as the synthetic clause (both `1` and
`'true'` count).

Tests: the marker fixtures previously used an invented
`metadata: { marker: { kind } }` shape — self-confirming, since the code reads
the same assumption. They now use shapes captured verbatim from real persisted
part rows (live counts: inbox 740, interrupt 168, message 805), including the
sibling fields beside `kind` (`from`/`sessionId`, `intent`/`origin`,
`peer`/`expectReply`), which also proves the predicate does not depend on
`marker` having exactly one key. Session id and peer name are placeholders.

Five cases added: ignored-only keeps the lock; ignored part alongside genuine
operator text still RELEASES it (the guard against suppressing a legitimate
release); numeric `ignored: 1`; and interrupt + message marker kinds.

RED verified by reverting the query alone — the two ignored cases fail
(23 pass / 2 fail) and pass again with the clause restored (25/0).
@ualtinok
ualtinok merged commit 437435b into cortexkit:master Aug 6, 2026
11 of 14 checks passed
@alfonso-magic-context

Copy link
Copy Markdown
Collaborator

Merged — and verified your core claim against a live opencode.db before merging: zero rows carry $.synthetic on the message table while the part table carries it throughout, exactly as you measured. The ALL-parts semantics with the vacuous-ALL fence and the @mention agent-part case are precisely the right call — classifying a real prompt with a synthetic agent part as injected would have been the worse inverse bug. Appreciated the archaeology on the comment/test agreeing with each other while both being wrong; both are fixed by this. Ships in the next release.

ualtinok added a commit that referenced this pull request Aug 6, 2026
…alesce + migration 45, permission regex pair, persisted deny verdict (v73), #273 part-shape pins, home-git containment gate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants